package com.stone.databinding.data;

import androidx.databinding.Bindable;
import androidx.databinding.ObservableInt;

import com.stone.databinding.BR;
import com.stone.databinding.util.ObservableViewModel;
import com.stone.databinding.util.Timer;

import java.util.ArrayList;
import java.util.TimerTask;

import static com.stone.databinding.util.ConverterUtil.cleanSecondsString;

/**
 * A ViewModel that contains the logic to control a work/rest interval timer.
 *
 * It uses different ways to expose data for the view:
 *  * Observable Fields
 *  * Bindable properties
 *  * Methods for user actions
 *
 * Data Binding features:
 *
 *  <b>One-way data binding</b>
 *
 *  See [workTimeLeft] and [restTimeLeft]. They are updated from the ViewModel and the UI is
 *  refreshed automatically
 *
 *  One-way also applies to user actions. See [workTimeIncrease] and how a lambda expression in
 *  the layout is built:
 *
 *    `android:onClick="@{() -> viewmodel.workTimeIncrease()}"`
 *
 *  <b>Two-way data binding</b>
 *
 *  * Simple: See `timerRunning`. It toggles between start and pause with a single line on the
 *  layout:
 *
 *    `android:checked="@={viewmodel.timerRunning}"`
 *
 *  * Custom: [numberOfSets] uses the most advanced form of 2-way Data Binding.
 *  See [com.example.android.databinding.twowaysample.ui.NumberOfSetsBindingAdapters].
 */
public class IntervalTimerViewModel extends ObservableViewModel {
    public static final int INITIAL_SECONDS_PER_WORK_SET = 5; // Seconds
    public static final int INITIAL_SECONDS_PER_REST_SET = 2; // Seconds
    public static final int INITIAL_NUMBER_OF_SETS = 5;

    private Timer timer;

    public IntervalTimerViewModel(Timer timer) {
        this.timer = timer;
    }

    /* Observable fields. When their values change (set method is called) they send updates to
        the UI automatically. */
    private ObservableInt timePerWorkSet = new ObservableInt(INITIAL_SECONDS_PER_WORK_SET * 10); // tenths
    private ObservableInt timePerRestSet = new ObservableInt(INITIAL_SECONDS_PER_REST_SET * 10); // tenths
    private ObservableInt workTimeLeft = new ObservableInt(timePerWorkSet.get()); // tenths
    private ObservableInt restTimeLeft = new ObservableInt(timePerRestSet.get()); // tenths

    public ObservableInt getTimePerWorkSet() {
        return timePerWorkSet;
    }

    public void setTimePerWorkSet(ObservableInt timePerWorkSet) {
        this.timePerWorkSet = timePerWorkSet;
    }

    public ObservableInt getTimePerRestSet() {
        return timePerRestSet;
    }

    public void setTimePerRestSet(ObservableInt timePerRestSet) {
        this.timePerRestSet = timePerRestSet;
    }

    public ObservableInt getWorkTimeLeft() {
        return workTimeLeft;
    }

    public void setWorkTimeLeft(ObservableInt workTimeLeft) {
        this.workTimeLeft = workTimeLeft;
    }

    public ObservableInt getRestTimeLeft() {
        return restTimeLeft;
    }

    public void setRestTimeLeft(ObservableInt restTimeLeft) {
        this.restTimeLeft = restTimeLeft;
    }

    public int getNumberOfSetsTotal() {
        return numberOfSetsTotal;
    }

    public void setNumberOfSetsTotal(int numberOfSetsTotal) {
        this.numberOfSetsTotal = numberOfSetsTotal;
    }

    public int getNumberOfSetsElapsed() {
        return numberOfSetsElapsed;
    }

    public void setNumberOfSetsElapsed(int numberOfSetsElapsed) {
        this.numberOfSetsElapsed = numberOfSetsElapsed;
    }

    public TimerStates getState() {
        return state;
    }

    public void setState(TimerStates state) {
        this.state = state;
    }

    public StartedStages getStage() {
        return stage;
    }

    public void setStage(StartedStages stage) {
        this.stage = stage;
    }

    /**
     * Used to indicate to the UI that the timer is running and to receive start/pause commands.
     *
     * A @Bindable property is a more flexible way to create an observable field. Use it with
     * two-way data binding. When the property changes in the ViewModel,
     * `notifyPropertyChanged(BR.timerRunning)` must be called so the system fetches the new value
     * with the getter and notifies the observers.
     *
     * User actions come through the setter, using two-way data binding.
     */
    private boolean timerRunning;

    @Bindable
    public boolean isTimerRunning() {
        return state == TimerStates.STARTED;
    }

    @Bindable
    public void setTimerRunning(boolean value) {
        if (value) {
            startButtonClicked();
        } else {
            pauseButtonClicked();
        }
    }

    /* The number of sets is handled using a custom two-way data binding approach.
    The EditText bound to this property displays a string ("Sets: 3/5") which is different from
    the number the user will input (5).

    The property exposes a getter and the view changes the value via the setter. Using @Bindable
    creates a Data Binding property but it's not automatically updated, you need to call
    `notifyPropertyChanged(BR.numberOfSets)` when it changes.
    */

    private int numberOfSetsTotal = INITIAL_NUMBER_OF_SETS;
    private int numberOfSetsElapsed = 0;
    private ArrayList<Integer> numberOfSets = new ArrayList<>();

    @Bindable
    public ArrayList<Integer> getNumberOfSets() {
        ArrayList<Integer> result = new ArrayList<>();
        result.add(numberOfSetsElapsed);
        result.add(numberOfSetsTotal);
        return result;
    }

    @Bindable
    public void setNumberOfSets(ArrayList<Integer> numberOfSets) {
        if (numberOfSets == null || numberOfSets.size() == 0) {
            return;
        }
        int newTotal = numberOfSets.get(1);
        if (this.numberOfSets == null || this.numberOfSets.size() == 0 || newTotal == this.numberOfSets.get(1))
            return;
        if (newTotal != 0 && newTotal > numberOfSetsElapsed) {
            this.numberOfSets.clear();
            this.numberOfSets.addAll(numberOfSets);
            numberOfSetsTotal = newTotal;
        }

        // Even if the input is empty, force a refresh of the view
        notifyPropertyChanged(BR.numberOfSets);
    }

    /**
     * Used to control some animations.
     */
    private boolean inWorkingStage;

    @Bindable
    public boolean isInWorkingStage() {
        return stage == StartedStages.WORKING;
    }

    @Bindable
    public void setInWorkingStage(boolean inWorkingStage) {
        this.inWorkingStage = inWorkingStage;
    }

    /**
     * Called from the UI, parses a new user-entered value.
     */
    public void timePerRestSetChanged(CharSequence newValue) {
        try {
            timePerRestSet.set(cleanSecondsString(newValue.toString()));
        } catch (NumberFormatException e) {
            return;
        }
        if (!isRestTimeAndRunning()) {
            restTimeLeft.set(timePerRestSet.get());
        }
    }

    /**
     * Called from the UI, parses a new user-entered value.
     */
    public void timePerWorkSetChanged(CharSequence newValue) {
        try {
            timePerWorkSet.set(cleanSecondsString(newValue.toString()));
        } catch (NumberFormatException e) {
            return;
        }
        if (!timerRunning) {
            workTimeLeft.set(timePerWorkSet.get());
        }
    }

    /**
     * Methods bound to buttons that increase/decrease times and number of sets.
     */

    public void restTimeIncrease(){
        timePerSetIncrease(timePerRestSet, 1, 0);
    }

    public void workTimeIncrease() {
        timePerSetIncrease(timePerWorkSet, 1, 0);
    }

    public void setsIncrease() {
        numberOfSetsTotal += 1;
        notifyPropertyChanged(BR.numberOfSets);
    }

    public void restTimeDecrease() {
        timePerSetIncrease(timePerRestSet, -1, 0);
    }

    public void workTimeDecrease() {
        timePerSetIncrease(timePerWorkSet, -1, 10);
    }

    public void setsDecrease() {
        if (numberOfSetsTotal > numberOfSetsElapsed + 1) {
            numberOfSetsTotal -= 1;
            notifyPropertyChanged(BR.numberOfSets);
        }
    }

    /**
     * Resets timers and state. Called from the UI.
     */
    public void stopButtonClicked() {
        resetTimers();
        numberOfSetsElapsed = 0;
        state = TimerStates.STOPPED;
        stage = StartedStages.WORKING; // Reset for the next set
        timer.reset();

        notifyPropertyChanged(BR.timerRunning);
        notifyPropertyChanged(BR.inWorkingStage);
        notifyPropertyChanged(BR.numberOfSets);
    }

    private TimerStates state = TimerStates.STOPPED;
    private StartedStages stage = StartedStages.WORKING;

    /**
     * Increases or decreases the work or rest time by a set value, depending on the sign of
     * the parameter.
     *
     * @param timePerSet the value holder to be updated
     * @param sign  1 to increase, -1 to decrease.
     */
    private void timePerSetIncrease(ObservableInt timePerSet, int sign, int min) {
        if (timePerSet.get() < 10 && sign < 0)
            return;
        // Increase the time in chunks
        roundTimeIncrease(timePerSet, sign, min);
        if (state == TimerStates.STOPPED) {
            // If stopped, update the timers right away
            workTimeLeft.set(timePerWorkSet.get());
            restTimeLeft.set(timePerRestSet.get());
        } else {
            // If running or paused, the timers need to be calculated
            updateCountdowns();
        }
    }

    /**
     * True if the work time has passed and we're currently resting.
     */
    private boolean isRestTimeAndRunning() {
        return (state == TimerStates.PAUSED || state == TimerStates.STARTED)
                && workTimeLeft.get() == 0;
    }

    /**
     * Make increasing and decreasing times a bit nicer by adding chunks.
     */
    private void roundTimeIncrease(ObservableInt timePerSet, int sign, int min) {
        int currentValue = timePerSet.get();
        int newValue = 0;
        if (currentValue < 100) {
            newValue = timePerSet.get() + sign * 10;
        } else if (currentValue < 600) {
            newValue = (int) Math.round(currentValue / 50.0) * 50 + 50 * sign;
        } else {
            newValue = (int) Math.round(currentValue / 100.0) * 100 + 100 * sign;
        }
        timePerSet.set(Math.max(min, newValue));
    }

    /**
     * Start the timer!
     */
    private void startButtonClicked() {
        switch (state) {
            case PAUSED:
                pausedToStarted();
                break;
            case STOPPED:
                stoppedToStarted();
                break;
            case STARTED:
            default:
                //do nothing
                break;

        }

        TimerTask task = new TimerTask() {
            @Override
            public void run() {
                if (state == TimerStates.STARTED) {
                    updateCountdowns();
                }
            }
        };

        // Schedule timer every 100ms to update the counters.
        timer.start(task);
    }

    /**
     * Pause the timer
     */
    private void pauseButtonClicked() {
        if (state == TimerStates.STARTED) {
            startedToPaused();
        }
        notifyPropertyChanged(BR.timerRunning);
    }

    /* STOPPED -> STARTED */
    private void stoppedToStarted() {
        // Set the start time
        timer.resetStartTime();
        state = TimerStates.STARTED;
        stage = StartedStages.WORKING;

        notifyPropertyChanged(BR.inWorkingStage);
        notifyPropertyChanged(BR.timerRunning);
    }

    /* PAUSED -> STARTED */
    private void pausedToStarted() {
        // We're measuring the time we're paused, so just add it to the start time
        timer.updatePausedTime();
        state = TimerStates.STARTED;

        notifyPropertyChanged(BR.timerRunning);
    }

    /* STARTED -> PAUSED */
    private void startedToPaused() {
        state = TimerStates.PAUSED;
        timer.resetPauseTime();
    }

    private void updateCountdowns() {
        if (state == TimerStates.STOPPED) {
            resetTimers();
            return;
        }

        long elapsed = -1;
        if (state == TimerStates.PAUSED) {
            elapsed = timer.getPausedTime();
        } else {
            elapsed = timer.getElapsedTime();
        }

        if (stage == StartedStages.RESTING) {
            updateRestCountdowns(elapsed);
        } else {
            // work
            updateWorkCountdowns(elapsed);
        }
    }

    private void updateWorkCountdowns(long elapsed) {
        stage = StartedStages.WORKING;
        int newTimeLeft = (int) (timePerWorkSet.get() - (elapsed / 100));
        if (newTimeLeft <= 0) {
            workoutFinished();
        }
        workTimeLeft.set(Math.max(newTimeLeft, 0));
    }

    private void updateRestCountdowns(long elapsed) {
        // Calculate the countdown time with the start time
        int newRestTimeLeft = (int) (timePerRestSet.get() - (elapsed / 100));
        restTimeLeft.set(Math.max(newRestTimeLeft, 0));

        if (newRestTimeLeft <= 0) { // Rest time is up
            numberOfSetsElapsed += 1;
            resetTimers();
            if (numberOfSetsElapsed >= numberOfSetsTotal) { // End
                timerFinished();
            } else { // End of set
                setFinished();
            }
        }
    }

    /* WORKING -> RESTING */
    private void workoutFinished() {
        timer.resetStartTime();
        stage = StartedStages.RESTING;
        notifyPropertyChanged(BR.inWorkingStage);
    }

    /* RESTING -> WORKING */
    private void setFinished() {
        timer.resetStartTime();
        stage = StartedStages.WORKING;

        notifyPropertyChanged(BR.inWorkingStage);
        notifyPropertyChanged(BR.numberOfSets);
    }

    /* RESTING -> STOPPED */
    private void timerFinished() {
        state = TimerStates.STOPPED;
        stage = StartedStages.WORKING; // Reset for the next set
        timer.reset();
        notifyPropertyChanged(BR.timerRunning);
        numberOfSetsElapsed = 0;

        notifyPropertyChanged(BR.inWorkingStage);
        notifyPropertyChanged(BR.numberOfSets);
    }

    private void resetTimers() {
        // Reset counters
        workTimeLeft.set(timePerWorkSet.get());

        // Set the start time
        restTimeLeft.set(timePerRestSet.get());
    }

    enum TimerStates {STOPPED, STARTED, PAUSED}

    enum StartedStages {WORKING, RESTING}
}
